--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 782372ea92805938e35f118ecf7d3263f51f1ae8
Parents : 7b7750d
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-24T18:01:50-05:00
feat(tests): add comprehensive tests for android_push_bridge and update version references in frontend tests
Changes
6 files changed, 371 insertions(+), 11 deletions(-)
Diff
diff --git a/tests/backend/test_app_status_tracking.py b/tests/backend/test_app_status_tracking.py
index 5cb047a6..62d4bab3 100644
--- a/tests/backend/test_app_status_tracking.py
+++ b/tests/backend/test_app_status_tracking.py
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
import RNS
+import meshchatx
from meshchatx.meshchat import ReticulumMeshChat
@@ -96,8 +97,10 @@ async def test_app_status_endpoints(mock_rns_minimal, temp_dir):
app_instance.config.set("tutorial_seen", True)
assert app_instance.config.get("tutorial_seen") == "true"
- app_instance.config.set("changelog_seen_version", "4.6.0")
- assert app_instance.config.get("changelog_seen_version") == "4.6.0"
+ app_instance.config.set("changelog_seen_version", meshchatx.__version__)
+ assert (
+ app_instance.config.get("changelog_seen_version") == meshchatx.__version__
+ )
# Test app_info returns these values
with ExitStack() as info_stack:
@@ -113,4 +116,4 @@ async def test_app_status_endpoints(mock_rns_minimal, temp_dir):
assert val == "true"
val = app_instance.config.get("changelog_seen_version")
- assert val == "4.6.0"
+ assert val == meshchatx.__version__
diff --git a/tests/frontend/AppModals.test.js b/tests/frontend/AppModals.test.js
index aedbb659..5cfbb233 100644
--- a/tests/frontend/AppModals.test.js
+++ b/tests/frontend/AppModals.test.js
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import App from "../../meshchatx/src/frontend/components/App.vue";
+import { appPackageVersion } from "./fixtures/repoPackageVersion.js";
import { createRouter, createWebHashHistory } from "vue-router";
import { createI18n } from "vue-i18n";
import { createVuetify } from "vuetify";
@@ -62,9 +63,9 @@ describe("App.vue Modals", () => {
return Promise.resolve({
data: {
app_info: {
- version: "4.6.0",
+ version: appPackageVersion,
tutorial_seen: true,
- changelog_seen_version: "4.6.0",
+ changelog_seen_version: appPackageVersion,
},
},
});
@@ -94,7 +95,7 @@ describe("App.vue Modals", () => {
return Promise.resolve({
data: {
app_info: {
- version: "4.6.0",
+ version: appPackageVersion,
tutorial_seen: false,
changelog_seen_version: "0.0.0",
},
@@ -153,7 +154,7 @@ describe("App.vue Modals", () => {
return Promise.resolve({
data: {
app_info: {
- version: "4.6.0",
+ version: appPackageVersion,
tutorial_seen: true,
changelog_seen_version: "3.9.0",
},
@@ -161,7 +162,9 @@ describe("App.vue Modals", () => {
});
}
if (url === "/api/v1/app/changelog") {
- return Promise.resolve({ data: { html: "<h1>New Features</h1>", version: "4.6.0" } });
+ return Promise.resolve({
+ data: { html: "<h1>New Features</h1>", version: appPackageVersion },
+ });
}
if (url === "/api/v1/config") return Promise.resolve({ data: { config: { theme: "dark" } } });
if (url === "/api/v1/auth/status") return Promise.resolve({ data: { auth_enabled: false } });
diff --git a/tests/frontend/ChangelogModal.test.js b/tests/frontend/ChangelogModal.test.js
index 276d46c8..c5fee60e 100644
--- a/tests/frontend/ChangelogModal.test.js
+++ b/tests/frontend/ChangelogModal.test.js
@@ -2,6 +2,7 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import ChangelogModal from "@/components/ChangelogModal.vue";
import { createVuetify } from "vuetify";
+import { appPackageVersion } from "./fixtures/repoPackageVersion.js";
const vuetify = createVuetify();
@@ -78,7 +79,7 @@ describe("ChangelogModal.vue", () => {
axiosMock.get.mockResolvedValue({
data: {
html: "<h1>Test</h1>",
- version: "4.6.0",
+ version: appPackageVersion,
},
});
@@ -95,7 +96,7 @@ describe("ChangelogModal.vue", () => {
axiosMock.get.mockResolvedValue({
data: {
html: "<h1>Test</h1>",
- version: "4.6.0",
+ version: appPackageVersion,
},
});
@@ -114,7 +115,7 @@ describe("ChangelogModal.vue", () => {
axiosMock.get.mockResolvedValue({
data: {
html: "<h1>Test</h1>",
- version: "4.6.0",
+ version: appPackageVersion,
},
});
diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 8362dcc4..ca9456a6 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -149,6 +149,45 @@ describe("ConversationViewer.vue", () => {
expect(wrapper.vm.newMessageImages).toHaveLength(0);
});
+ it("updates windowWidth on visualViewport resize and removes listeners on unmount", async () => {
+ const vvListeners = { resize: [], scroll: [] };
+ const vv = {
+ addEventListener(ev, fn) {
+ if (ev === "resize") {
+ vvListeners.resize.push(fn);
+ }
+ if (ev === "scroll") {
+ vvListeners.scroll.push(fn);
+ }
+ },
+ removeEventListener(ev, fn) {
+ if (ev === "resize") {
+ vvListeners.resize = vvListeners.resize.filter((f) => f !== fn);
+ }
+ if (ev === "scroll") {
+ vvListeners.scroll = vvListeners.scroll.filter((f) => f !== fn);
+ }
+ },
+ };
+ vi.stubGlobal("visualViewport", vv);
+
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 300 });
+ const wrapper = mountConversationViewer();
+ await wrapper.vm.$nextTick();
+
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 700 });
+ expect(vvListeners.resize.length).toBeGreaterThan(0);
+ vvListeners.resize[0]();
+ expect(wrapper.vm.windowWidth).toBe(700);
+
+ vvListeners.scroll[0]();
+ expect(wrapper.vm.windowWidth).toBe(700);
+
+ wrapper.unmount();
+ expect(vvListeners.resize).toHaveLength(0);
+ expect(vvListeners.scroll).toHaveLength(0);
+ });
+
it("onMessagePaste adds multiple images from a single paste event", () => {
const wrapper = mountConversationViewer();
const f1 = new File([""], "a.png", { type: "image/png" });
diff --git a/tests/frontend/fixtures/repoPackageVersion.js b/tests/frontend/fixtures/repoPackageVersion.js
new file mode 100644
index 00000000..974779c0
--- /dev/null
+++ b/tests/frontend/fixtures/repoPackageVersion.js
@@ -0,0 +1,7 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../..");
+
+export const appPackageVersion = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version;
diff --git a/tests/test_android_push_bridge.py b/tests/test_android_push_bridge.py
new file mode 100644
index 00000000..6668f255
--- /dev/null
+++ b/tests/test_android_push_bridge.py
@@ -0,0 +1,307 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+
+import pytest
+
+import meshchatx.android_push_bridge as android_push_bridge
+from meshchatx.android_push_bridge import (
+ install_websocket_hook,
+ lxmf_delivery_notification_text,
+)
+
+
+@pytest.fixture(autouse=True)
+def reset_ws_hook_flag():
+ android_push_bridge._ws_hook_installed = False
+ yield
+ android_push_bridge._ws_hook_installed = False
+
+
+def test_lxmf_delivery_skips_wrong_type():
+ assert lxmf_delivery_notification_text({"type": "config"}) is None
+
+
+def test_lxmf_delivery_skips_when_sieve_suppresses():
+ assert (
+ lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "sieve_suppress_notifications": True,
+ "remote_identity_name": "Alice",
+ "lxmf_message": {"is_incoming": True, "title": "Hi", "content": "Body"},
+ },
+ )
+ is None
+ )
+
+
+def test_lxmf_delivery_skips_outbound():
+ assert (
+ lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Bob",
+ "lxmf_message": {
+ "is_incoming": False,
+ "title": "Hi",
+ "content": "Body",
+ },
+ },
+ )
+ is None
+ )
+
+
+def test_lxmf_delivery_skips_telemetry_only():
+ assert (
+ lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Peer",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "",
+ "content": "",
+ "fields": {"telemetry": {"lat": 1}},
+ },
+ },
+ )
+ is None
+ )
+
+
+def test_lxmf_delivery_incoming_title_and_body():
+ assert lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Charlie",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "Subject",
+ "content": "Hello there",
+ },
+ },
+ ) == ("Charlie", "Subject\nHello there")
+
+
+def test_lxmf_delivery_truncates_long_content():
+ long_body = "x" * 250
+ title, body = lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Dana",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "",
+ "content": long_body,
+ },
+ },
+ )
+ assert title == "Dana"
+ assert body.endswith("...")
+ assert len(body) == 200
+
+
+def test_lxmf_delivery_reaction():
+ assert lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Eve",
+ "lxmf_message": {
+ "is_incoming": True,
+ "is_reaction": True,
+ "reaction_emoji": "thumbsup",
+ "title": "",
+ "content": "",
+ },
+ },
+ ) == ("Eve", "Reaction thumbsup")
+
+
+def test_lxmf_delivery_default_sender():
+ t, b = lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "Only title",
+ "content": "",
+ },
+ },
+ )
+ assert t == "Mesh"
+ assert b == "Only title"
+
+
+def test_lxmf_delivery_image_fields_only():
+ assert lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Fran",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "",
+ "content": "",
+ "fields": {"image": {"image_type": "png", "image_size": 1}},
+ },
+ },
+ ) == ("Fran", "Image message")
+
+
+def test_lxmf_delivery_audio_fields_only():
+ assert lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Gus",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "",
+ "content": "",
+ "fields": {"audio": {"audio_mode": 1, "audio_size": 1}},
+ },
+ },
+ ) == ("Gus", "Audio message")
+
+
+def test_lxmf_delivery_attachment_fields_only():
+ assert lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Hal",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "",
+ "content": "",
+ "fields": {
+ "file_attachments": [{"file_name": "a.bin", "file_size": 1}]
+ },
+ },
+ },
+ ) == ("Hal", "Attachment")
+
+
+def test_lxmf_delivery_skips_non_dict_message():
+ assert (
+ lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Ian",
+ "lxmf_message": "not-a-dict",
+ },
+ )
+ is None
+ )
+
+
+def test_after_websocket_broadcast_ignores_non_string(monkeypatch):
+ calls = []
+ monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: calls.append(a))
+ android_push_bridge._after_websocket_broadcast(123)
+ assert calls == []
+
+
+def test_after_websocket_broadcast_ignores_invalid_json(monkeypatch):
+ calls = []
+ monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: calls.append(a))
+ android_push_bridge._after_websocket_broadcast("{not json")
+ assert calls == []
+
+
+def test_after_websocket_broadcast_skips_when_notification_text_none(monkeypatch):
+ calls = []
+ monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: calls.append(a))
+ android_push_bridge._after_websocket_broadcast(json.dumps({"type": "config"}))
+ assert calls == []
+
+
+def test_after_websocket_broadcast_notifies_with_dedupe(monkeypatch):
+ calls = []
+ monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: calls.append(a))
+ payload = {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Jules",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "Hi",
+ "content": "There",
+ "hash": "abcdef0123456789",
+ },
+ }
+ android_push_bridge._after_websocket_broadcast(json.dumps(payload))
+ assert len(calls) == 1
+ assert calls[0][0] == "Jules"
+ assert calls[0][1] == "Hi\nThere"
+ assert calls[0][2] == "abcdef0123456789"
+
+
+def test_after_websocket_broadcast_short_hash_skips_dedupe(monkeypatch):
+ calls = []
+ monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: calls.append(a))
+ payload = {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Kim",
+ "lxmf_message": {
+ "is_incoming": True,
+ "title": "T",
+ "content": "",
+ "hash": "short",
+ },
+ }
+ android_push_bridge._after_websocket_broadcast(json.dumps(payload))
+ assert calls[0][2] is None
+
+
+def test_install_skips_when_not_chaquopy_android(monkeypatch):
+ monkeypatch.setattr(android_push_bridge, "_is_chaquopy_android", lambda: False)
+
+ class Rmc:
+ async def websocket_broadcast(self, data):
+ return data
+
+ original = Rmc.websocket_broadcast
+ install_websocket_hook(Rmc)
+ assert Rmc.websocket_broadcast is original
+
+
+@pytest.mark.asyncio
+async def test_install_wraps_broadcast_and_notifies(monkeypatch):
+ monkeypatch.setattr(android_push_bridge, "_is_chaquopy_android", lambda: True)
+ calls = []
+ monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: calls.append(a))
+
+ class Rmc:
+ async def websocket_broadcast(self, data):
+ return "ok"
+
+ unwrapped = Rmc.websocket_broadcast
+ install_websocket_hook(Rmc)
+ payload = {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Lee",
+ "lxmf_message": {"is_incoming": True, "title": "A", "content": "B"},
+ }
+ result = await Rmc().websocket_broadcast(json.dumps(payload))
+ assert result == "ok"
+ assert len(calls) == 1
+ assert calls[0][:2] == ("Lee", "A\nB")
+ Rmc.websocket_broadcast = unwrapped
+
+
+@pytest.mark.asyncio
+async def test_install_is_idempotent(monkeypatch):
+ monkeypatch.setattr(android_push_bridge, "_is_chaquopy_android", lambda: True)
+ invocations = []
+
+ class Rmc:
+ async def websocket_broadcast(self, data):
+ invocations.append("orig")
+ return None
+
+ unwrapped = Rmc.websocket_broadcast
+ install_websocket_hook(Rmc)
+ install_websocket_hook(Rmc)
+ monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: None)
+ await Rmc().websocket_broadcast(json.dumps({"type": "config"}))
+ assert invocations == ["orig"]
+ Rmc.websocket_broadcast = unwrapped
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────